Skip to content

test(ios): reproduce #4394 keyboard opens on right-edge tap of non-cursor thought#4534

Draft
ethan-james wants to merge 16 commits into
cybersemics:mainfrom
ethan-james:ethan-james-fix-caret-focus-wdio-test
Draft

test(ios): reproduce #4394 keyboard opens on right-edge tap of non-cursor thought#4534
ethan-james wants to merge 16 commits into
cybersemics:mainfrom
ethan-james:ethan-james-fix-caret-focus-wdio-test

Conversation

@ethan-james

@ethan-james ethan-james commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

#4394

Summary

The iOS caret test 'Focus is prevented after clearing the cursor' was tapping the right edge with a synthetic point-tap. A zero-radius synthetic tap never triggers iOS Safari's touch-adjustment ("fuzzy tapping") retargeting, so the bug could never reproduce and the test always passed — it did not actually guard against #4394.

This PR reworks the test to faithfully reproduce #4394's exact steps on a non-cursor thought:

  1. Create thought Hello
  2. Dismiss the keyboard
  3. Cursor Back (swipe right) to set the cursor to null — so Hello becomes a non-cursor thought (verified editing === null before the tap)
  4. Tap ~4px past the right edge, vertically centered, using a finger-sized contact area (width/height/pressure)

Mechanism

The finger-sized contact area triggers iOS Safari's touch-adjustment heuristic:

touchstart (…) tgt=DIV#thought-annotation   ← touch lands on the annotation overlay
touchend   (…) tgt=DIV#thought-annotation   ← editable's onTouchEnd never fires → no preventDefault
mousedown  (…) tgt=DIV[EDITABLE]            ← Safari retargets the synthesized mouse events into the editable
focusin        tgt=DIV[EDITABLE]            ← focus proceeds
→ *** KEYBOARD OPEN ***

Because the touch events are dispatched on the annotation (not the editable), the editable's onTouchEnd never runs to preventDefault; Safari then redirects the mouse events into the editable and focus proceeds, so the virtual keyboard incorrectly opens.

Root cause

The onMouseDown else branch does call e.preventDefault(), which blocks the native focus default of the tap — so native focus is not the vector. The keyboard opens because of a stale, never-reset offsetRef in useEditMode.ts:

  • offsetRef.current is assigned when a thought that has the cursor is tapped, but it is never reset back to null.
  • On the edge tap of the now-non-cursor thought, the retargeted mouseup runs onMouseUp, which reads that stale offset and calls setCaretOffsetselection.set.
  • selection.set sets a DOM range inside the contentEditable, which programmatically focuses it (and opens the keyboard) even though onMouseDown preventDefaulted the native focus.

This is exactly what PR #4371 fixed by deleting onMouseUp/offsetRef and moving setCaretOffset into a guarded onMouseDown. caretFocusIsolated.ts proves this against bare DOM primitives: a reproduce fixture (mousedown preventDefault + a mouseup selection.set with a stale offset) leaks focus/keyboard, while a control fixture (no mouseup selection = post-#4371) blocks focus and keeps the keyboard down.

Expected outcome

On this pre-#4371 branch the reworked spec passes, positively demonstrating that #4394 reproduces (isKeyboardShown() returns true for a non-cursor thought). Once focus is correctly prevented for non-cursor thoughts, the assertion should be flipped to toBe(false) to convert it into a standing regression guard.

Environment: reproduces on both iOS 17 and iOS 18

The stale-offsetRef focus path is device- and version-independent code logic, and real BrowserStack devices confirm the bug reproduces on both iOS 17 and iOS 18 (the finger-sized contact area triggers the touch-adjustment retargeting on both). An earlier revision assumed the bug was iOS-18-only, but that assumption was never actually exercised on iOS 17 — the spec had been excluded from the iOS 17 device. Running it there showed the keyboard opens on iOS 17 as well.

The #4394 regression is isolated into its own spec file (src/e2e/iOS/__tests__/caretFocus.ts) and BrowserStack is configured with three capabilities:

  • iPhone 15 Plus / iOS 17 — runs the entire suite except caretFocus.ts / caretFocusIsolated.ts (unchanged from before).
  • iPhone 16 Pro Max / iOS 18 — runs caretFocus.ts and caretFocusIsolated.ts.
  • iPhone 15 Plus / iOS 17 — runs only caretFocus.ts, to positively verify the bug is not iOS-18-specific.

This scopes the reproduction to dedicated devices and keeps the rest of the iOS suite on its original device/OS.

Notes

  • Native priming tap. A priming step (a real native tap on the thought's center, then keyboard dismissal) is required so the synthetic edge-touch reliably wins the timing race that a real finger triggers on its own. It uses a native performActions tap rather than a webview element.click() because on real hardware only a native touch focuses the editable and opens the keyboard (a webview click does not, which previously caused the priming's keyboard dismissal to fail on BrowserStack). Priming while Hello has the cursor is also what leaves offsetRef.current set (and never reset) on pre-Move setCaretOffset into onMouseDown and preventAutoscrollEnd back into onFocus #4371.
  • No releaseActions() is called after performActions — Safari/XCUITest does not support the DELETE /actions endpoint (the gesture helper avoids it for the same reason).
  • getElementRectByScreen computes the status-bar offset per-device, so the tap coordinates map correctly across the simulator and BrowserStack hardware.

ethan-james and others added 2 commits July 6, 2026 10:04
…p in caret test

The 'Focus is prevented after clearing the cursor' test tapped the right
edge with a synthetic point-tap, which never triggered iOS Safari's
touch-adjustment retargeting, so the bug could not reproduce and the test
always passed.

Rework the test to the exact cybersemics#4394 repro on a non-cursor thought:
- create "Hello", dismiss the keyboard, Cursor Back (swipe right) to null
  the cursor so "Hello" becomes a non-cursor thought
- tap ~4px past the right edge, vertically centered, with a finger-sized
  contact area (width/height/pressure)

The finger-sized contact triggers Safari's touch-adjustment: the touch
lands on the #thought-annotation overlay (so the editable's onTouchEnd
never runs to preventDefault), while the synthesized mousedown is
retargeted into the editable, so focus proceeds and the virtual keyboard
incorrectly opens.

This test now fails on this branch, faithfully demonstrating cybersemics#4394. It
will pass once focus is correctly prevented for non-cursor thoughts.

cybersemics#4394

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@github-actions

github-actions Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Vercel preview: https://em-bw1pw9wra-cybersemics.vercel.app

The clickThought priming was tuned to the local simulator's timing race and
crashed on BrowserStack real hardware (the priming tap did not reopen the
keyboard, so the follow-up Done dismissal found no button). Strip the priming
so the test follows cybersemics#4394's literal repro steps, which real hardware should
reproduce like a real finger.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…S 18

BrowserStack Safari 17 did not trigger the touch-adjustment retargeting that
cybersemics#4394 depends on, so the synthetic fat-tap passed. Match the local simulator
environment (iPhone 16 Pro Max, iOS 18) where the bug provably reproduces, and
restore the clickThought priming that the reproduction requires.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ad of webview click

On BrowserStack real devices a webview element.click() (clickThought) does not
focus the editable or open the keyboard, so the priming's follow-up Done
dismissal crashed with 'element wasn't found'. Replace the priming with a native
performActions tap on the thought's center, which focuses the editable and opens
the keyboard on real hardware, then restore the webview context for the
subsequent cursorBack + edge-tap steps.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Safari/XCUITest does not support the DELETE /actions endpoint that
browser.releaseActions() calls, which aborted the test right after the native
priming tap. performActions does not require an explicit release here (the
gesture helper avoids it for the same reason).

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…capability

The regression only reproduces on iOS 18's Safari touch-adjustment heuristic.
Move the test into its own spec file (caretFocus.ts) and give BrowserStack two
capabilities: the existing iOS 17 device runs the whole suite except this spec,
and a new iOS 18 device (iPhone 16 Pro Max) runs only this spec. This scopes the
intended failure and keeps the rest of the suite off iOS 18.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
ethan-james and others added 6 commits July 7, 2026 13:14
Replace hideKeyboardByTappingDone (which depends on em's Done accessory,
absent for a bare contentEditable) with a webview-context blur, and make
nativeTap restore the webview context in a finally so a failed tap can't
strand the session in NATIVE_APP and break the next test.
Re-model the isolated caret-focus diagnostics around the confirmed root
cause: pre-cybersemics#4371 useEditMode never resets offsetRef.current, so the
retargeted mouseup runs onMouseUp -> setCaretOffset -> selection.set and
focuses the editable despite onMouseDown preventDefaulting the native
focus. The reproduce spec adds a stale-offset mouseup selection (models
pre-cybersemics#4371) and shows focus/keyboard proceed; the control spec omits it
(models post-cybersemics#4371) and shows focus stays blocked.

Make caretFocus.ts version-aware and add a dedicated iOS 17 BrowserStack
capability so the same edge tap positively asserts the keyboard opens on
iOS 18 but stays down on iOS 17, verifying the bug is iOS-18-specific.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Real BrowserStack devices show the right-edge tap opens the keyboard on
iOS 17 as well as iOS 18, refuting the iOS-18-only premise. The stale
offsetRef -> onMouseUp -> selection.set focus path is version-independent,
so caretFocus.ts now asserts the keyboard opens on both devices (dropping
the version-aware branching) and the config/isolated comments are updated
to match.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
ethan-james and others added 3 commits July 8, 2026 12:04
Since cybersemics#4394 reproduces on the default iOS 17 device, the dedicated iOS 18
and iOS 17 caret capabilities (and the suite excludes) are unnecessary.
Revert wdio.browserstack.conf.ts to the single-device baseline so both
caret specs run as part of the normal suite, and update the spec comments
to match.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
… the fix PR

Remove caretFocusIsolated.ts and its reference from the caretFocus.ts
header. cybersemics#4534 now carries only the primary caretFocus.ts reproduction
spec, mirroring cybersemics#4407 (the fix PR) on the earlier pre-cybersemics#4371 branch.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Fold the caretFocus.ts reproduction spec back into the Caret describe
block in caret.ts and delete the standalone file. No behavior change; the
spec still asserts the keyboard opens on the pre-cybersemics#4371 branch.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant